studio/src/pages/[organizationSlug]/members.tsx (622 lines of code) (raw):

import { useApplyParams } from "@/components/analytics/use-apply-params"; import { UserContext } from "@/components/app-provider"; import { EmptyState } from "@/components/empty-state"; import { getDashboardLayout } from "@/components/layout/dashboard-layout"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Loader } from "@/components/ui/loader"; import { Pagination } from "@/components/ui/pagination"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableWrapper, } from "@/components/ui/table"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Toolbar } from "@/components/ui/toolbar"; import { useToast } from "@/components/ui/use-toast"; import { useFeature } from "@/hooks/use-feature"; import { SubmitHandler, useZodForm } from "@/hooks/use-form"; import { useUser } from "@/hooks/use-user"; import { NextPageWithLayout } from "@/lib/page"; import { cn, getHighestPriorityRole } from "@/lib/utils"; import { createConnectQueryKey, useMutation, useQuery, } from "@connectrpc/connect-query"; import { EllipsisVerticalIcon, ExclamationTriangleIcon, UserPlusIcon, } from "@heroicons/react/24/outline"; import { Cross1Icon, MagnifyingGlassIcon } from "@radix-ui/react-icons"; import { useQueryClient } from "@tanstack/react-query"; import { EnumStatusCode } from "@wundergraph/cosmo-connect/dist/common/common_pb"; import { getOrganizationMembers, getPendingOrganizationMembers, inviteUser, isMemberLimitReached, removeInvitation, removeOrganizationMember, updateOrgMemberRole, } from "@wundergraph/cosmo-connect/dist/platform/v1/platform-PlatformService_connectquery"; import { sentenceCase } from "change-case"; import Link from "next/link"; import { useRouter } from "next/router"; import { useContext, useState } from "react"; import { useDebounce } from "use-debounce"; import { z } from "zod"; const usePaginationParams = () => { const router = useRouter(); const pageNumber = router.query.page ? parseInt(router.query.page as string) : 1; const pageSize = Number.parseInt((router.query.pageSize as string) || "10"); const limit = pageSize > 50 ? 50 : pageSize; const offset = (pageNumber - 1) * limit; const search = (router.query.search as string) || ""; return { pageNumber, pageSize, limit, offset, search, }; }; const emailInputSchema = z.object({ email: z.string().email(), }); type EmailInput = z.infer<typeof emailInputSchema>; const InviteForm = ({ onSuccess }: { onSuccess: () => void }) => { const { register, formState: { isValid, errors }, reset, handleSubmit, } = useZodForm<EmailInput>({ mode: "onChange", schema: emailInputSchema, }); const { mutate, isPending } = useMutation(inviteUser); const { toast } = useToast(); const sendToast = (description: string) => { const { id } = toast({ description, duration: 3000 }); }; const onSubmit: SubmitHandler<EmailInput> = (data) => { mutate( { email: data.email }, { onSuccess: (d) => { sendToast(d.response?.details || "Invited member successfully."); onSuccess(); reset(); }, onError: (error) => { sendToast("Could not invite the member. Please try again."); }, }, ); }; return ( <form className="flex gap-x-4" onSubmit={handleSubmit(onSubmit)}> <div className="flex-1"> <Input placeholder="janedoe@example.com" className="w-full" type="text" {...register("email")} /> {errors.email && ( <span className="mt-2 text-sm text-destructive"> {errors.email.message} </span> )} </div> <Button type="submit" disabled={!isValid} variant="default" isLoading={isPending} > Invite </Button> </form> ); }; const roleOptions: { [key: string]: { label: string; newRole: string }[]; } = { admin: [ { label: "Demote to developer", newRole: "developer", }, { label: "Demote to viewer", newRole: "viewer", }, ], developer: [ { label: "Promote to admin", newRole: "admin", }, { label: "Demote to viewer", newRole: "viewer", }, ], viewer: [ { label: "Promote to admin", newRole: "admin", }, { label: "Promote to developer", newRole: "developer", }, ], }; const MemberCard = ({ email, role, memberUserID, acceptedInvite, isAdmin, isCurrentUser, active, refresh, }: { email: string; role?: string; memberUserID: string; acceptedInvite: boolean; isAdmin: boolean; isCurrentUser: boolean; active?: boolean; refresh: () => void; }) => { const user = useContext(UserContext); const { mutate: resendInvitation } = useMutation(inviteUser); const { mutate: revokeInvitation } = useMutation(removeInvitation); const { mutate: removeMember } = useMutation(removeOrganizationMember); const { mutate: updateUserRole } = useMutation(updateOrgMemberRole); const { toast, update } = useToast(); return ( <TableRow> <TableCell>{email}</TableCell> {acceptedInvite && ( <TableCell> {active === false && <Badge variant="destructive">Disabled</Badge>} </TableCell> )} <TableCell> <div className="flex h-6 items-center justify-between gap-x-4 text-muted-foreground"> <div className={cn({ "pr-[14px]": isAdmin && isCurrentUser })}> {acceptedInvite && role ? ( <span className="text-sm">{sentenceCase(role)}</span> ) : ( <span className="text-sm text-gray-800 dark:text-gray-400"> Pending </span> )} </div> <div> {isAdmin && !isCurrentUser && ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="icon-sm"> <EllipsisVerticalIcon className="h-4 w-4" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-[180px]"> {!acceptedInvite && ( <DropdownMenuItem onClick={() => { const { id } = toast({ description: "Inviting member...", }); resendInvitation( { email }, { onSuccess: (d) => { update({ description: d.response?.details || "Invited member successfully.", duration: 2000, id: id, }); }, onError: (error) => { update({ description: "Could not invite the member. Please try again.", duration: 3000, id: id, }); }, }, ); }} > Resend invitation </DropdownMenuItem> )} <DropdownMenuItem onClick={() => { if (acceptedInvite) { removeMember( { email }, { onSuccess: (d) => { toast({ description: d.response?.details || "Removed member successfully.", duration: 3000, }); refresh(); }, onError: (error) => { toast({ description: "Could not remove member. Please try again.", duration: 3000, }); }, }, ); } else { revokeInvitation( { email }, { onSuccess: (d) => { toast({ description: d.response?.details || "Removed invitation successfully.", duration: 3000, }); refresh(); }, onError: (error) => { toast({ description: "Could not remove invitation. Please try again.", duration: 3000, }); }, }, ); } }} > {acceptedInvite ? "Remove member" : "Remove invitation"} </DropdownMenuItem> {role && roleOptions[role].map(({ label, newRole }) => ( <DropdownMenuItem key={newRole} onClick={() => { updateUserRole( { userID: user?.id, orgMemberUserID: memberUserID, role: newRole, }, { onSuccess: (d) => { toast({ description: d.response?.details || `Updated the role to ${newRole} successfully.`, duration: 3000, }); refresh(); }, onError: (error) => { toast({ description: `Could not update role to ${newRole}. Please try again.`, duration: 3000, }); }, }, ); }} > {label} </DropdownMenuItem> ))} </DropdownMenuContent> </DropdownMenu> )} </div> </div> </TableCell> </TableRow> ); }; const PendingInvitations = () => { const user = useUser(); const isAdmin = user?.currentOrganization.roles.includes("admin") ?? false; const { limit, offset, pageNumber, search } = usePaginationParams(); const [debouncedSearch] = useDebounce(search, 500); const { data, isLoading, error, refetch } = useQuery( getPendingOrganizationMembers, { pagination: { limit, offset, }, search: debouncedSearch, }, ); const noOfPages = Math.ceil((data?.totalCount ?? 0) / limit); if (isLoading) return <Loader fullscreen />; if (error || data?.response?.code !== EnumStatusCode.OK || !user) return ( <EmptyState icon={<ExclamationTriangleIcon />} title="Could not retrieve pending invites of this organization." description={ data?.response?.details || error?.message || "Please try again" } actions={<Button onClick={() => refetch()}>Retry</Button>} /> ); if (!data?.pendingInvitations) return null; return ( <> <TableWrapper className="max-h-full"> <Table> <TableHeader> <TableRow> <TableHead className="w-full">Email</TableHead> <TableHead className="">Status</TableHead> </TableRow> </TableHeader> <TableBody> {data.pendingInvitations?.map((member) => { return ( <MemberCard key={member.userID} email={member.email} memberUserID={member.userID} acceptedInvite={false} isAdmin={isAdmin || false} isCurrentUser={member.email === user.email} refresh={() => refetch()} /> ); })} {data.pendingInvitations.length === 0 && ( <p className="w-full p-8 text-center italic text-muted-foreground"> No invitations found </p> )} </TableBody> </Table> </TableWrapper> <Pagination limit={limit} noOfPages={noOfPages} pageNumber={pageNumber} /> </> ); }; const AcceptedMembers = () => { const user = useUser(); const isAdmin = user?.currentOrganization.roles.includes("admin") ?? false; const { limit, offset, pageNumber, search } = usePaginationParams(); const [debouncedSearch] = useDebounce(search, 500); const { data, isLoading, error, refetch } = useQuery(getOrganizationMembers, { pagination: { limit, offset, }, search: debouncedSearch, }); const noOfPages = Math.ceil((data?.totalCount ?? 0) / limit); if (isLoading) return <Loader fullscreen />; if (error || data?.response?.code !== EnumStatusCode.OK || !user) return ( <EmptyState icon={<ExclamationTriangleIcon />} title="Could not retrieve the members of this organization." description={ data?.response?.details || error?.message || "Please try again" } actions={<Button onClick={() => refetch()}>Retry</Button>} /> ); if (!data?.members) return null; return ( <> <TableWrapper className="max-h-full"> <Table> <TableHeader> <TableRow> <TableHead className="w-full">Email</TableHead> <TableHead className=""></TableHead> <TableHead className="">Role</TableHead> </TableRow> </TableHeader> <TableBody> {data.members?.map((member) => { return ( <MemberCard key={member.userID} email={member.email} role={getHighestPriorityRole({ userRoles: member.roles })} memberUserID={member.userID} acceptedInvite={true} isAdmin={isAdmin || false} isCurrentUser={member.email === user.email} active={member.active} refresh={() => refetch()} /> ); })} </TableBody> {data.members.length === 0 && ( <p className="w-full p-8 text-center italic text-muted-foreground"> No members found </p> )} </Table> </TableWrapper> <Pagination limit={limit} noOfPages={noOfPages} pageNumber={pageNumber} /> </> ); }; const MembersToolbar = () => { const usersFeature = useFeature("users"); const user = useUser(); const organizationSlug = user?.currentOrganization.slug; const isAdmin = user?.currentOrganization.roles.includes("admin") ?? false; const client = useQueryClient(); const { limit, offset, search } = usePaginationParams(); const { data } = useQuery(isMemberLimitReached); const limitReached = data?.limitReached ?? false; if (!isAdmin) { return null; } return ( <Toolbar className="w-auto"> <Dialog> <DialogTrigger asChild> <Button> <UserPlusIcon className="mr-2 h-4 w-4" /> Invite Member </Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle> {!limitReached ? "Invite Member" : "User limit reached"} </DialogTitle> <DialogDescription> {!limitReached ? "Send an invite to an email id to invite them into your organization" : `You have added ${data?.memberCount} of ${usersFeature?.limit} users, please upgrade your account to increase your limits.`} </DialogDescription> </DialogHeader> {!limitReached && ( <InviteForm onSuccess={() => { const pendingKey = createConnectQueryKey( getPendingOrganizationMembers, { pagination: { limit, offset, }, search, }, ); client.invalidateQueries({ queryKey: pendingKey, }); }} /> )} {limitReached && ( <Button variant="outline" asChild> <Link href={`/${organizationSlug}/billing`}>View plans</Link> </Button> )} </DialogContent> </Dialog> </Toolbar> ); }; const MembersPage: NextPageWithLayout = () => { const router = useRouter(); const tab = router.query.tab || "current"; const applyParams = useApplyParams(); const [search, setSearch] = useState(""); const [debouncedSearch] = useDebounce(search, 500); const { limit, offset } = usePaginationParams(); const { data } = useQuery(getPendingOrganizationMembers, { pagination: { limit, offset, }, search: debouncedSearch, }); return ( <div className="flex h-full flex-col gap-y-6"> <div className="flex flex-col justify-between gap-y-4 md:flex-row md:items-center"> <Tabs onValueChange={(v) => { applyParams({ tab: v, page: null, pageSize: null, }); }} defaultValue="current" > <TabsList> <TabsTrigger value="current">Current</TabsTrigger> <TabsTrigger value="pending" className="gap-x-1"> Pending{" "} {(data?.totalCount ?? 0) > 0 && ( <Badge className="px-2">{data?.totalCount}</Badge> )} </TabsTrigger> </TabsList> </Tabs> <div className="relative"> <MagnifyingGlassIcon className="absolute bottom-0 left-3 top-0 my-auto" /> <Input placeholder="Search" className="pl-8 pr-10" value={search} onChange={(e) => { setSearch(e.target.value); applyParams({ search: e.target.value }); }} /> {search && ( <Button variant="ghost" className="absolute bottom-0 right-0 top-0 my-auto rounded-l-none" onClick={() => { setSearch(""); applyParams({ search: null }); }} > <Cross1Icon /> </Button> )} </div> </div> {tab === "current" ? <AcceptedMembers /> : <PendingInvitations />} </div> ); }; MembersPage.getLayout = (page) => { return getDashboardLayout( page, "Members", "Manage all the members of your organization", null, <MembersToolbar />, ); }; export default MembersPage;